﻿/*
Copyright (c) 2010 The Developer Journey Company

Permission is hereby granted, free of charge, to any person obtaining a copy 
of this software and associated documentation files (the "Software"), to deal 
in the Software without restriction, including without limitation the rights 
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
copies of the Software, and to permit persons to whom the Software is 
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all 
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 
THE SOFTWARE. 
*/

using System;
using System.Collections.Generic;
using System.Text;
using System.Linq;

namespace CommunityMegaphone
{
    /// <summary>
    /// This class builds various query URLs for CommunityMegaphone.com's 
    /// OData service. It supports distance-based queries specifying a ZIP 
    /// code as the center point or a specific latitude and longitude pair.
    /// Filters for specific event types, audience IDs and states are also
    /// supported. The caller may do simple pagination by controlling the
    /// page size and the page number embedded in the resulting query. The
    /// default URL of the CommunityMegaphone OData service may be modified
    /// as well as the contents of the OData $orderby phrase.
    /// </summary>
    /// <example>
    /// // Build a query to fetch page 2 (at 10 events per page) of Code
    /// // Camps and Firestarter events for audience ID 1 within 50 miles
    /// // of the ZIP code 23060.
    /// var queryURL = new CommunityMegaphone.ODataQueryBuilder()
    /// {
    ///     EventTypeFilter = "CC,FS",
    ///     AudienceIDFilter = "1",
    ///     PageSize = 10,
    ///     PageNum = 1 // zero-based so 1 here means get page 2
    /// }.GetEventsByZipCodeURL(zip: "23060", distanceInMiles: 50);
    /// </example>
    public class ODataQueryBuilder
    {
        // defaulting, backing value for the BaseServiceURL property
        private string _baseServiceURL = 
            "http://communitymegaphone.com/ws/CMEventDS.svc/";

        /// <summary>
        /// The base URL for the CommunityMegaPhone.com service. 
        /// This property has a default value of 
        /// "http://communitymegaphone.com/ws/CMEventDS.svc/"
        /// so there is no need to set it unless you are connecting 
        /// to a non-production service instance. The provided URL
        /// should have a trailing slash character.
        /// </summary>
        /// <example>
        /// "http://communitymegaphone.com/ws/CMEventDS.svc/" to query
        /// the production service instance.
        /// </example>
        /// <exception cref="ArgumentNullException">
        /// The BaseServiceURL may not be null.
        /// </exception>
        /// <exception cref="ArgumentException">
        /// The BaseServiceURL may not be empty.
        /// </exception>
        public string BaseServiceURL
        {
            set
            {
                if (value == null)
                    throw new ArgumentNullException(
                        "BaseURL", "BaseServiceURL may not be null.");
                value = value.Trim();
                if (value.Length == 0)
                    throw new ArgumentException(
                        "BaseServiceURL may not be empty.", "BaseURL");

                _baseServiceURL = value + 
                    (value.EndsWith("/") ? String.Empty : "/");
            }
        }

        // defaulting, backing value for the OrderByPhrase
        private string _orderBy = "starttime,title";

        /// <summary>
        /// A comma separated list of entry element IDs to be used
        /// by the OData source to order the results of the query.
        /// The default value is "starttime,title". No attempt is
        /// made to validate the individual entry element ID names 
        /// that are provided in the list.
        /// </summary>
        /// <exception cref="ArgumentNullException">
        /// The OrderByPhrase may not be null.
        /// </exception>
        /// <exception cref="ArgumentException">
        /// The OrderByPhrase may not be empty.
        /// </exception>
        public string OrderByPhrase
        {
            set
            {
                if (value == null)
                    throw new ArgumentNullException("OrderByPhrase",
                        "The OrderByPhrase may not be null.");
                value = value.Trim();
                if (value.Length == 0)
                    throw new ArgumentException("The OrderByPhrase may " +
                        "not be empty.", "OrderByPhrase");

                _orderBy = value;
            }
        }

        // defaulting, backing collection for the EventTypeFilter property
        private List<string> _eventTypes = new List<string>();

        /// <summary>
        /// A comma separated list of event types to include in the 
        /// query results. These values may be obtained by invoking
        /// the CommunityMegaphone.com service's GetEventTypes()
        /// web method. If no event types are specified, all event
        /// types will be included in the query results. If one or 
        /// more event type values are provided, no validations with 
        /// respect to the target CommnuityMegaphone.com OData service 
        /// are performed.
        /// </summary>
        /// <example>
        /// "CC,UG,FS" to include Code Camps, User Groups and Firestarters
        /// in the query results.
        /// </example>
        public string EventTypeFilter
        {
            set
            {
                _eventTypes.Clear();
                if (value == null || value.Trim().Length == 0)
                    return;
                _eventTypes.AddRange(
                    value.Split(',')
                        .Select(s => s.Trim())
                        .Where(s => s.Length > 0));
            }
        }

        // defaulting, backing collection for the AudienceIDFilter property
        private List<int> _audienceIDs = new List<int>();

        /// <summary>
        /// A comma separated list of numeric audience IDs to include in the 
        /// query results. These values may be obtained by invoking
        /// the CommunityMegaphone.com service's GetAudiences()
        /// web method. If no audience IDs are specified, all audience IDs
        /// will be included in the query results. If one or more audience 
        /// ID values are provided, they will be validated as integers but 
        /// no validation with respect to the target CommnuityMegaphone.com 
        /// OData service is performed.
        /// </summary>
        /// <example>
        /// "1,3" to include audience IDs 1 and 3 in the query results.
        /// </example>
        public string AudienceIDFilter
        {
            set
            {
                _audienceIDs.Clear();
                if (value == null || value.Trim().Length == 0)
                    return;
                _audienceIDs.AddRange(
                    value.Split(',')
                        .Select(s => s.Trim())
                        .Where(s => s.Length > 0)
                        .Select(s => Convert.ToInt32(s)));
            }
        }

        // defaulting, backing collection for the StateFilter property
        private List<string> _states = new List<string>();

        /// <summary>
        /// A comma separated list of states to include in the 
        /// query results. These values may be obtained by invoking
        /// the CommunityMegaphone.com service's GetStates()
        /// web method. If no states are specified, all states will
        /// be included in the query results. If one or  more state 
        /// values are provided, no validations with respect to the 
        /// target CommnuityMegaphone.com OData service are performed.
        /// NOTE: You may filter by state for any query type. However, 
        /// distance queries, e.g. those that require a distance 
        /// parameter to be supplied, typically wouldn't require the 
        /// use of a state filter.
        /// </summary>
        /// <example>
        /// "VA,WV,NC" to include the states Virginia, Maryland and 
        /// North Carolina in the query results.
        /// </example>
        public string StateFilter
        {
            set
            {
                _states.Clear();
                if (value == null || value.Trim().Length == 0)
                    return;
                _states.AddRange(
                    value.Split(',')
                        .Select(s => s.Trim())
                        .Where(s => s.Length > 0));
            }
        }

        // defaulting, backing value for the PageSize property
        private byte _pageSize = 255;

        /// <summary>
        /// The number of events to return from the query, a.k.a the page size.
        /// The default is 255. The page size may not be zero.
        /// </summary>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The PageSize may not be zero.
        /// </exception>
        public byte PageSize
        {
            set
            {
                if (value == 0)
                    throw new ArgumentOutOfRangeException("PageSize", 
                        "The PageSize may not be zero.");

                _pageSize = value;
            }
        }

        /// <summary>
        /// The zero-based page number of results to be returned by the query.
        /// The default page number is zero.
        /// </summary>
        public byte PageNum { private get; set; }

        /// <summary>
        /// Gets the URL for CommunityMegaphone.com's ApprovedEvents 
        /// service operation filtered by the active audience ID, state and 
        /// event type filters and paging values on this object. The results 
        /// will be ordered first by event start time then by title.
        /// </summary>
        /// <returns>
        /// The full query URL as a string.
        /// </returns>
        /// <example>
        /// // Yield the URL:
        /// // http://communitymegaphone.com/ws/CMEventDS.svc/ApprovedEvents?$orderby=starttime,title&$skip=0&$top=255&$filter=(substringof('1',audienceid)+eq+true+or+substringof('3',audienceid)+eq+true)+and+(state+eq+'VA'+or+state+eq+'MD'+or+state+eq+'DC')
        /// // from the code:
        /// var queryURL = new CommunityMegaphone.ODataQueryBuilder()
        /// {
        ///     StateFilter = "VA,MD,DC",
        ///     AudienceIDFilter = "1,3"
        /// }.ApprovedEventsURL();
        /// </example>
        public string ApprovedEventsURL()
        {
            var result = new StringBuilder(_baseServiceURL);
            result.AppendFormat("ApprovedEvents?$orderby={0}", _orderBy);
            result.AppendFormat("&$skip={0}&$top={1}{2}",
                (int)_pageSize * PageNum, _pageSize, FilterPhrase);
            return result.ToString();
        }

        /// <summary>
        /// Gets the URL for CommunityMegaphone.com's GetEventsByZipCode 
        /// service operation for a specified zip code and distance in miles.
        /// The query URL will be formatted to filter by the active audience 
        /// ID, state and  event type filters and paging values on this 
        /// object. The results will be ordered first by event start time then 
        /// by title.
        /// </summary>
        /// <param name="zip">
        /// The five digit ZIP code to use as the center point for the radius
        /// based search.
        /// </param>
        /// <param name="distanceInMiles">
        /// The radius of the search specified in miles which may not be zero.
        /// </param>
        /// <returns>
        /// The full query URL as a string.
        /// </returns>
        /// <exception cref="ArgumentNullException">
        /// The fiveDigitZIP may not be null.
        /// </exception>
        /// <exception cref="ArgumentException">
        /// The fiveDigitZIP must be exactly five characters in length.
        /// The fiveDigitZIP may contain only the digits 0-9.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The distanceInMiles must not be zero.
        /// </exception>
        /// <example>
        /// // Yield the URL:
        /// // http://communitymegaphone.com/ws/CMEventDS.svc/GetEventsByZipCode?$orderby=starttime,title&$skip=0&$top=255&$filter=(substringof('1',audienceid)+eq+true)+and+(eventtype+eq+'CC'+or+eventtype+eq+'UG'+or+eventtype+eq+'FS')&ZipCode='23060'&Dist=100
        /// // from the code:
        /// var queryURL = new CommunityMegaphone.ODataQueryBuilder()
        /// {
        ///     EventTypeFilter = "CC,UG,FS",
        ///     AudienceIDFilter = "1"
        /// }.GetEventsByZipCodeURL(zip: "23060", distanceInMiles: 100);
        /// </example>
        public string GetEventsByZipCodeURL(string fiveDigitZIP, 
            int distanceInMiles)
        {
            if (fiveDigitZIP == null)
                throw new ArgumentNullException("fiveDigitZIP", 
                    "The fiveDigitZIP may not be null.");
            fiveDigitZIP = fiveDigitZIP.Trim();
            if (fiveDigitZIP.Length != 5)
                throw new ArgumentException("The fiveDigitZIP must be " +
                    "exactly five characters in length.", "fiveDigitZIP");
            foreach (char c in fiveDigitZIP)
                if (!Char.IsDigit(c))
                    throw new ArgumentException("The fiveDigitZIP may " +
                        "contain only the digits 0-9.", "fiveDigitZip");
            if (distanceInMiles == 0)
                throw new ArgumentOutOfRangeException("distanceInMiles",
                    "The distanceInMiles must not be zero.");

            var result = new StringBuilder(_baseServiceURL);
            result.AppendFormat("GetEventsByZipCode?$orderby={0}", _orderBy);
            result.AppendFormat(
                "&$skip={0}&$top={1}{2}&ZipCode='{3}'&Dist={4}",
                (int)_pageSize * PageNum, _pageSize, FilterPhrase, 
                fiveDigitZIP, distanceInMiles);
            return result.ToString();
        }

        /// <summary>
        /// Gets the URL for CommunityMegaphone.com's GetEventsByDistance 
        /// service operation for a specified latitude, longitude and distance 
        /// in miles. The query URL will be formatted to filter by the active 
        /// audience ID, state and  event type filters and paging values on 
        /// this object. The results will be ordered first by event start time 
        /// then by title.
        /// </summary>
        /// <param name="latitude">
        /// The latitude number to use as the center point for the radius
        /// based search which may be in the range -90 to 90.
        /// </param>
        /// <param name="longitude">
        /// The longitude number to use as the center point for the radius
        /// based search which may be in the rage -180 to 180.
        /// </param>
        /// <param name="distanceInMiles">
        /// The radius of the search specified in miles which may not be zero.
        /// </param>
        /// <returns>
        /// The full query URL as a string.
        /// </returns>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The latitude must be in the range -90 to 90 degrees.
        /// The longitude must be in the range -180 to 180 degrees.
        /// The distanceInMiles must not be zero.
        /// </exception>
        /// <example>
        /// // Yield the URL:
        /// // http://communitymegaphone.com/ws/CMEventDS.svc/GetEventsByDistance?$orderby=starttime,title&$skip=0&$top=255&$filter=(substringof('1',audienceid)+eq+true)+and+(eventtype+eq+'CC'+or+eventtype+eq+'UG'+or+eventtype+eq+'FS')&Lat='37.5536111'&Lon='-77.4605556'&Dist=100
        /// // From the code:
        /// var queryURL = new CommunityMegaphone.ODataQueryBuilder()
        /// {
        ///     EventTypeFilter = "CC,UG,FS",
        ///     AudienceIDFilter = "1"
        /// }.GetEventsByDistanceURL(latitude: 37.5536111, longitude: -77.4605556, distanceInMiles: 100);
        /// </example>
        public string GetEventsByDistanceURL(float latitude, 
            float longitude, ushort distanceInMiles)
        {
            if (latitude > 90.0f || latitude < -90.0f)
                throw new ArgumentOutOfRangeException("latitude", 
                    "The latitude must be in the range -90 to 90 degrees.");
            if (longitude > 180.0f || longitude < -180.0f)
                throw new ArgumentOutOfRangeException("longitude",
                    "The longitude must be in the range -180 to 180 degrees.");
            if (distanceInMiles == 0)
                throw new ArgumentOutOfRangeException("distanceInMiles",
                    "The distanceInMiles must not be zero.");

            var result = new StringBuilder(_baseServiceURL);
            result.AppendFormat("GetEventsByDistance?$orderby={0}", _orderBy);
            result.AppendFormat(
                "&$skip={0}&$top={1}{2}&Lat='{3}'&Lon='{4}'&Dist={5}",
                (int)_pageSize * PageNum, _pageSize, FilterPhrase, latitude, 
                longitude, distanceInMiles);
            return result.ToString();
        }

        // aggregate filter phrases into an OData $filter
        private string FilterPhrase
        {
            get
            {
                var result = new StringBuilder("&$filter=");
                bool phrasesEmitted = false;
                foreach (var phrase in FilterGroupPhrases())
                {
                    if (String.IsNullOrEmpty(phrase))
                        continue;
                    result.AppendFormat(
                        (phrasesEmitted) ? "+and+({0})" : "({0})",
                        phrase);
                    phrasesEmitted = true;
                }
                return phrasesEmitted ? result.ToString() : String.Empty;
            }
        }

        // yield a collection of filtering phrases for all groups
        private IEnumerable<string> FilterGroupPhrases()
        {
            yield return FilterGroupPhrase(_eventTypes,
                "eventtype+eq+'{0}'");
            yield return FilterGroupPhrase(_audienceIDs,
                "substringof('{0}',audienceid)+eq+true");
            yield return FilterGroupPhrase(_states,
                "state+eq+'{0}'");
        }

        // produce the filter phrase for a specific group
        // takes the input sequence, a format for the OData $filter
        // phrase and a recursion depth
        private static string FilterGroupPhrase<T>(
            IEnumerable<T> filterValues, string phraseFormat, 
            int depth = 0)
        {
            if (filterValues == null || filterValues.Count() == 0)
                return "";
            string result = String.Format(
                (depth == 0)
                    ? phraseFormat
                    : "+or+" + phraseFormat, filterValues.First());
            return result
                + FilterGroupPhrase(filterValues.Skip(1), 
                    phraseFormat, ++depth);
        }
    }
}
